<?php
/**
 * 智能门禁设备TCP服务器 - 集成会员数据版本
 */
// 引入ThinkAdmin框架
require_once __DIR__ . '/../../vendor/autoload.php';

error_reporting(E_ALL);
ini_set('display_errors', 'on');
set_time_limit(0);

// 读取 .env（简易解析），用于数据库和密钥配置
function loadDotEnv(string $path): array {
    if (!is_file($path)) return [];
    $vars = [];
    foreach (file($path) as $line) {
        $line = trim($line);
        if ($line === '' || $line[0] === '#') continue;
        $pos = strpos($line, '=');
        if ($pos === false) continue;
        $key = trim(substr($line, 0, $pos));
        $val = trim(substr($line, $pos + 1));
        if ((strlen($val) >= 2) && (($val[0] === '"' && substr($val, -1) === '"') || ($val[0] === "'" && substr($val, -1) === "'"))) {
            $val = substr($val, 1, -1);
        }
        $vars[$key] = $val;
    }
    return $vars;
}


// MySQL连接配置（全局变量）
$env = loadDotEnv(__DIR__ . '/../../.env');
$dbConfig = [
    'host' => $env['DB_MYSQL_HOST'] ?? getenv('DB_MYSQL_HOST') ?? ($env['DB_HOST'] ?? getenv('DB_HOST') ?: '127.0.0.1'),
    'port' => $env['DB_MYSQL_PORT'] ?? getenv('DB_MYSQL_PORT') ?? ($env['DB_PORT'] ?? getenv('DB_PORT') ?: '3306'),
    'database' => $env['DB_MYSQL_DATABASE'] ?? getenv('DB_MYSQL_DATABASE') ?? ($env['DB_DATABASE'] ?? getenv('DB_DATABASE') ?: 'qiandao'),
    'username' => $env['DB_MYSQL_USERNAME'] ?? getenv('DB_MYSQL_USERNAME') ?? ($env['DB_USERNAME'] ?? getenv('DB_USERNAME') ?: 'root'),
    'password' => $env['DB_MYSQL_PASSWORD'] ?? getenv('DB_MYSQL_PASSWORD') ?? ($env['DB_PASSWORD'] ?? getenv('DB_PASSWORD') ?: ''),
    'charset' => $env['DB_MYSQL_CHARSET'] ?? getenv('DB_MYSQL_CHARSET') ?? ($env['DB_CHARSET'] ?? getenv('DB_CHARSET') ?: 'utf8mb4')
];

// 注册签名 APP_SECRET：优先 .env -> 环境变量 -> 默认
if (!defined('APP_SECRET')) {
    define('APP_SECRET', $env['DEVICE_APP_SECRET'] ?? getenv('DEVICE_APP_SECRET') ?: 'U2FsdGVkX19VU8Q');
}

// Redis连接配置（全局变量）
$redisConfig = [
    'host' => $env['REDIS_HOST'] ?? getenv('REDIS_HOST') ?: '127.0.0.1',
    'port' => $env['REDIS_PORT'] ?? getenv('REDIS_PORT') ?: 6379,
    'password' => $env['REDIS_PASSWORD'] ?? getenv('REDIS_PASSWORD') ?: '',
    'database' => $env['REDIS_DATABASE'] ?? getenv('REDIS_DATABASE') ?: 0
];

// Redis 连接实例（全局变量）
$redis = null;

// MySQL 连接池（全局变量）
$mysqlConnPool = [];
$mysqlConnPoolIndex = 0;
$lastMySQLPingTime = 0;
$mysqlPingInterval = 30; // 每30秒ping一次数据库

/**
 * 创建MySQL连接
 *
 * @return PDO|null
 */
function createMySQLConnection(): ?PDO {
    global $dbConfig;

    try {
        $dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['database']};charset={$dbConfig['charset']}";
        $pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password']);

        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
        // 设置连接超时和等待超时
        $pdo->setAttribute(PDO::ATTR_TIMEOUT, 5);

        logMsg("MySQL database connected successfully: {$dbConfig['host']}:{$dbConfig['port']}/{$dbConfig['database']}");
        return $pdo;
    } catch (\Throwable $e) {
        logMsg("Failed to connect MySQL database: " . $e->getMessage());
        return null;
    }
}

/**
 * 快速检查MySQL连接是否有效（不阻塞）
 *
 * @param PDO|null $pdo
 * @return bool
 */
function isConnectionAlive(?PDO $pdo): bool {
    if ($pdo === null) return false;

    try {
        // 使用非阻塞方式检查连接
        $pdo->query('SELECT 1');
        return true;
    } catch (\Throwable $e) {
        return false;
    }
}

/**
 * 检查并重连MySQL连接（改进版）
 *
 * @param PDO|null $pdo
 * @return PDO|null
 */
function checkAndReconnectMySQL(?PDO $pdo): ?PDO {
    // 快速检查连接是否有效
    if (isConnectionAlive($pdo)) {
        return $pdo;
    }

    logMsg("[MySQL] Connection lost, attempting to reconnect...");
    // 连接无效或为null，重新连接
    return createMySQLConnection();
}

/**
 * 定期保活MySQL连接（防止超时）
 * 应该在主循环中定期调用
 */
function keepMySQLAlive(?PDO $pdo): ?PDO {
    global $lastMySQLPingTime, $mysqlPingInterval;

    $now = time();

    // 每30秒ping一次
    if ($now - $lastMySQLPingTime >= $mysqlPingInterval) {
        $lastMySQLPingTime = $now;

        if (!isConnectionAlive($pdo)) {
            logMsg("[MySQL] Connection lost during keep-alive, reconnecting...");
            $pdo = createMySQLConnection();
        } else {
            logMsg("[MySQL] Keep-alive ping successful");
        }
    }

    return $pdo;
}

/**
 * 创建Redis连接
 *
 * @return Redis|null
 */
function createRedisConnection(): ?object {
    global $redisConfig;

    try {
        $redis = new Redis();
        $redis->connect($redisConfig['host'], $redisConfig['port'], 2);

        if (!empty($redisConfig['password'])) {
            $redis->auth($redisConfig['password']);
        }

        $redis->select($redisConfig['database']);

        logMsg("Redis connected successfully: {$redisConfig['host']}:{$redisConfig['port']}/{$redisConfig['database']}");
        return $redis;
    } catch (\Throwable $e) {
        logMsg("Failed to connect Redis: " . $e->getMessage());
        return null;
    }
}

/**
 * 获取Redis连接（单例模式）
 *
 * @return Redis|null
 */
function getRedisConnection(): ?object {
    global $redis;

    if ($redis === null) {
        $redis = createRedisConnection();
    }

    return $redis;
}

// 初始化MySQL数据库连接
$pdo = createMySQLConnection();
$frameworkReady = ($pdo !== null);

$host = '0.0.0.0';
$port = 8778;
$marker = "OFZL";
$version = 0x02;
$dtype = 0x01;

$logDir = __DIR__ . '/logs';
if (!is_dir($logDir)) @mkdir($logDir, 0777, true);

function logMsg(string $msg): void {
    // 日志已禁用
     //$file = __DIR__ . '/logs/' . date('Y-m-d') . '.log';
     //$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n";
     //file_put_contents($file, $line, FILE_APPEND);
     //echo $line; // 同时输出到控制台
}

/**
 * 将心跳请求放入Redis队列（异步处理）
 * 不在主线程中执行数据库操作，避免阻塞
 *
 * @param array $heartbeatData 心跳数据
 * @return bool 是否成功放入队列
 */
function queueHeartbeatAsync(array $heartbeatData): bool {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[HEARTBEAT_QUEUE] Redis connection failed, skipping async queue");
            return false;
        }

        $queueKey = "heartbeat:queue";
        $data = json_encode($heartbeatData, JSON_UNESCAPED_UNICODE);

        // 使用Redis列表作为队列，LPUSH添加到队列头
        $result = $redis->lPush($queueKey, $data);

        if ($result) {
            logMsg("[HEARTBEAT_QUEUE] Heartbeat queued for device {$heartbeatData['sn']}, queue size: {$result}");
        }

        return $result > 0;
    } catch (\Throwable $e) {
        logMsg("[HEARTBEAT_QUEUE] Error queuing heartbeat: " . $e->getMessage());
        return false;
    }
}

/**
 * 处理Redis队列中的心跳请求（后台处理）
 * 这个函数应该在单独的进程中定期调用
 */
function processHeartbeatQueue(): void {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[HEARTBEAT_PROCESSOR] Redis connection failed");
            return;
        }

        $pdo = checkAndReconnectMySQL(getDbConnection());
        if (!$pdo) {
            logMsg("[HEARTBEAT_PROCESSOR] MySQL connection failed");
            return;
        }

        $queueKey = "heartbeat:queue";

        // 批量处理队列中的心跳请求（每次最多处理100个）
        for ($i = 0; $i < 100; $i++) {
            // RPOP从队列尾取出一个元素
            $data = $redis->rPop($queueKey);

            if (!$data) {
                break; // 队列为空
            }

            $heartbeatData = json_decode($data, true);
            if (!is_array($heartbeatData)) {
                continue;
            }

            try {
                $sn = strval($heartbeatData['sn'] ?? '');
                $ipAddr = isset($heartbeatData['ipAddr']) ? strval($heartbeatData['ipAddr']) : null;
                $appVersion = isset($heartbeatData['appVersion']) ? strval($heartbeatData['appVersion']) : null;

                if ($sn !== '') {
                    updateDeviceHeartbeat($pdo, $sn, $ipAddr, $appVersion);
                    logMsg("[HEARTBEAT_PROCESSOR] Processed heartbeat for device {$sn}");
                }
            } catch (\Throwable $e) {
                logMsg("[HEARTBEAT_PROCESSOR] Error processing heartbeat: " . $e->getMessage());
            }
        }
    } catch (\Throwable $e) {
        logMsg("[HEARTBEAT_PROCESSOR] Fatal error: " . $e->getMessage());
    }
}

/**
 * 推送消息到WebSocket服务器（异步，不阻塞主线程）
 *
 * @param array $message 要推送的消息
 * @return bool 是否推送成功
 */
function pushToWebSocket(array $message): bool {
    try {
        // 【优化】记录开始时间，用于性能监控
        $startTime = microtime(true);

        // 【优化】使用非阻塞连接，超时时间改为 0.3 秒（降低延迟）
        $socket = @stream_socket_client('tcp://127.0.0.1:8780', $errno, $errstr, 0.3);

        if (!$socket) {
            $elapsed = round((microtime(true) - $startTime) * 1000, 2);
            logMsg("[WEBSOCKET] 连接失败 ({$elapsed}ms): {$errstr} ({$errno})，将消息放入队列");
            // 【改进】连接失败时，将消息放入 Redis 队列，由后台处理
            queueWebSocketMessageAsync($message);
            return false;
        }

        // 【改进】设置非阻塞模式，防止 fwrite 阻塞
        stream_set_blocking($socket, false);

        // 发送JSON消息（text协议，以换行符结尾）
        $jsonData = json_encode($message, JSON_UNESCAPED_UNICODE) . "\n";
        $written = @fwrite($socket, $jsonData);
        @fclose($socket);

        $elapsed = round((microtime(true) - $startTime) * 1000, 2);

        if ($written === false || $written === 0) {
            logMsg("[WEBSOCKET] 消息发送失败 ({$elapsed}ms)，将消息放入队列");
            queueWebSocketMessageAsync($message);
            return false;
        }

        // 【优化】只在语音播报时记录详细日志，其他消息简化日志
        $msgType = $message['type'] ?? 'unknown';
        if ($msgType === 'voice_play') {
            $voiceMsg = $message['voice_message'] ?? '';
            logMsg("[WEBSOCKET] 语音消息推送成功 ({$elapsed}ms): {$voiceMsg}");
        } else {
            logMsg("[WEBSOCKET] 消息推送成功 ({$elapsed}ms): type={$msgType}");
        }
        return true;

    } catch (\Exception $e) {
        $elapsed = round((microtime(true) - $startTime) * 1000, 2);
        logMsg("[WEBSOCKET] 推送异常 ({$elapsed}ms): " . $e->getMessage());
        queueWebSocketMessageAsync($message);
        return false;
    }
}

/**
 * 使用 Redis 检查是否重复刷脸（30秒内）
 * 【改进】避免查询大表 system_checkin_log，使用 Redis 缓存
 *
 * @param int $memberId 会员ID
 * @param string $checkTime 当前刷脸时间
 * @return bool 是否重复刷脸
 */
function checkDuplicateScanWithRedis(int $memberId, string $checkTime): bool {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[DUPLICATE_CHECK] Redis连接失败，跳过重复检查");
            return false;
        }

        $cacheKey = "last_scan:{$memberId}";
        $currentTime = strtotime($checkTime);

        // 获取上次刷脸时间
        $lastScanTime = $redis->get($cacheKey);

        if ($lastScanTime !== false) {
            $lastTime = intval($lastScanTime);
            $interval = $currentTime - $lastTime;

            // 如果间隔小于 30 秒，则为重复刷脸
            if ($interval < 30) {
                logMsg("[DUPLICATE_CHECK] 重复刷脸检测：会员 {$memberId}，间隔 {$interval}秒");
                return true;
            }
        }

        // 更新最后刷脸时间，设置过期时间为 60 秒
        $redis->setex($cacheKey, 60, $currentTime);
        logMsg("[DUPLICATE_CHECK] 更新会员 {$memberId} 的最后刷脸时间");

        return false;
    } catch (\Throwable $e) {
        logMsg("[DUPLICATE_CHECK] 错误: " . $e->getMessage());
        return false;
    }
}

/**
 * 将WebSocket消息放入异步队列
 *
 * @param array $message 要推送的消息
 * @return bool 是否成功放入队列
 */
function queueWebSocketMessageAsync(array $message): bool {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[WEBSOCKET_QUEUE] Redis连接失败，无法放入队列");
            return false;
        }

        $queueKey = "websocket:queue";
        $data = json_encode($message, JSON_UNESCAPED_UNICODE);

        // 使用Redis列表作为队列
        $result = $redis->lPush($queueKey, $data);

        if ($result) {
            logMsg("[WEBSOCKET_QUEUE] 消息已放入队列，队列大小: {$result}");
        }

        return $result > 0;
    } catch (\Throwable $e) {
        logMsg("[WEBSOCKET_QUEUE] 错误: " . $e->getMessage());
        return false;
    }
}

/**
 * 处理WebSocket消息队列（后台处理）
 * 从队列中取出消息，尝试推送到WebSocket服务器
 */
function processWebSocketQueue(): void {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[WEBSOCKET_QUEUE] Redis连接失败");
            return;
        }

        $queueKey = "websocket:queue";

        // 批量处理队列中的消息（每次最多处理10个）
        for ($i = 0; $i < 10; $i++) {
            $data = $redis->rPop($queueKey);

            if (!$data) {
                break; // 队列为空
            }

            $message = json_decode($data, true);
            if (!is_array($message)) {
                continue;
            }

            try {
                // 尝试推送到WebSocket服务器
                $socket = @stream_socket_client('tcp://127.0.0.1:8780', $errno, $errstr, 1);

                if ($socket) {
                    stream_set_blocking($socket, false);
                    $jsonData = json_encode($message, JSON_UNESCAPED_UNICODE) . "\n";
                    @fwrite($socket, $jsonData);
                    @fclose($socket);
                    logMsg("[WEBSOCKET_QUEUE] 消息已推送");
                } else {
                    logMsg("[WEBSOCKET_QUEUE] 连接失败，消息重新放入队列");
                    // 重新放入队列
                    $redis->lPush($queueKey, $data);
                }
            } catch (\Throwable $e) {
                logMsg("[WEBSOCKET_QUEUE] 处理消息错误: " . $e->getMessage());
            }
        }
    } catch (\Throwable $e) {
        logMsg("[WEBSOCKET_QUEUE] 致命错误: " . $e->getMessage());
    }
}

/**
 * 播放语音文件（通过WebSocket推送到浏览器，使用Web Speech API）
 *
 * @param string $voiceType 语音类型：success=签到成功, end=结束签到, duplicate=重复刷脸
 * @param array $extraData 额外数据（会员信息等）
 */
function playVoice(string $voiceType, array $extraData = []): void {
    // 【性能监控】记录开始时间
    $startTime = microtime(true);

    // 根据语音类型生成对应的文字提示
    $voiceMessage = '';

    switch ($voiceType) {
        case 'success':
            // 签到开始
            $memberName = $extraData['member_name'] ?? '用户';
            $voiceMessage = "{$memberName}，签到成功";
            break;
        case 'end':
            // 签到结束
            $memberName = $extraData['member_name'] ?? '用户';
            $points = $extraData['points'] ?? 0;
            $availablePoints = $extraData['available_points'] ?? 0;
            $voiceMessage = "{$memberName}，签到结束，本次获得{$points}积分，可用积分：{$availablePoints}";
            break;
        case 'duplicate':
            // 重复刷脸
            $voiceMessage = "请勿重复刷脸";
            break;
        default:
            $voiceMessage = "操作完成";
    }

    // 通过WebSocket推送到浏览器播放
    $wsMessage = [
        'type' => 'voice_play',
        'voice_message' => $voiceMessage,
        'voice_type' => $voiceType,
        'time' => date('Y-m-d H:i:s'),
        'data' => $extraData
    ];

    logMsg("[VOICE] Pushing voice message: {$voiceMessage}");
    pushToWebSocket($wsMessage);

    // 【性能监控】记录总耗时
    $elapsed = round((microtime(true) - $startTime) * 1000, 2);
    logMsg("[VOICE] Voice playback completed in {$elapsed}ms");
}

/**
 * 保存会员信息到Redis缓存
 *
 * @param int $memberId 会员ID
 * @param array $memberData 会员数据
 * @param int $ttl 缓存过期时间（秒），默认24小时
 * @return bool
 */
function saveMemberToCache(int $memberId, array $memberData, int $ttl = 86400): bool {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[CACHE] Redis connection failed, skipping cache save");
            return false;
        }

        $cacheKey = "member:{$memberId}";
        $result = $redis->setex($cacheKey, $ttl, json_encode($memberData, JSON_UNESCAPED_UNICODE));

        if ($result) {
            logMsg("[CACHE] Member {$memberId} saved to cache, TTL: {$ttl}s");
        } else {
            logMsg("[CACHE] Failed to save member {$memberId} to cache");
        }

        return $result;
    } catch (\Throwable $e) {
        logMsg("[CACHE] Error saving member to cache: " . $e->getMessage());
        return false;
    }
}

/**
 * 从Redis缓存获取会员信息
 *
 * @param int $memberId 会员ID
 * @return array|null
 */
function getMemberFromCache(int $memberId): ?array {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[CACHE] Redis connection failed, skipping cache get");
            return null;
        }

        $cacheKey = "member:{$memberId}";
        $data = $redis->get($cacheKey);

        if ($data) {
            $memberData = json_decode($data, true);
            logMsg("[CACHE] Member {$memberId} retrieved from cache");
            return $memberData;
        }

        logMsg("[CACHE] Member {$memberId} not found in cache");
        return null;
    } catch (\Throwable $e) {
        logMsg("[CACHE] Error getting member from cache: " . $e->getMessage());
        return null;
    }
}

/**
 * 从Redis缓存删除会员信息
 *
 * @param int $memberId 会员ID
 * @return bool
 */
function deleteMemberFromCache(int $memberId): bool {
    try {
        $redis = getRedisConnection();
        if (!$redis) {
            logMsg("[CACHE] Redis connection failed, skipping cache delete");
            return false;
        }

        $cacheKey = "member:{$memberId}";
        $result = $redis->del($cacheKey);

        logMsg("[CACHE] Member {$memberId} deleted from cache, result: {$result}");
        return $result > 0;
    } catch (\Throwable $e) {
        logMsg("[CACHE] Error deleting member from cache: " . $e->getMessage());
        return false;
    }
}


function generateUuid(): string {
    try {
        return bin2hex(random_bytes(16));
    } catch (\Throwable $e) {
        return uniqid('dev_', true);
    }
}

function generatePasswd(int $len = 6): string {
    $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    $out = '';
    for ($i = 0; $i < $len; $i++) {
        $out .= $chars[random_int(0, strlen($chars) - 1)];
    }
    return $out;
}

function getDeviceBySn(PDO $pdo, string $sn): ?array {
    $stmt = $pdo->prepare('SELECT * FROM checkin_device WHERE sn = ? LIMIT 1');
    $stmt->execute([$sn]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);
    return $row ?: null;
}

function upsertDeviceOnRegister(PDO $pdo, string $sn, ?string $deviceType, string $secret, int $protocolVersion = 2): array {
    $now = date('Y-m-d H:i:s');
    $exist = getDeviceBySn($pdo, $sn);

    $config = [];
    if ($exist && !empty($exist['config'])) {
        $decoded = json_decode($exist['config'], true);
        if (is_array($decoded)) { $config = $decoded; }
    }
    $config['secret'] = $secret;

    if ($exist) {
        $stmt = $pdo->prepare('UPDATE checkin_device SET passwd = ?, device_type = ?, protocol_version = ?, config = ?, update_time = ? WHERE sn = ?');
        $stmt->execute([md5($secret), $deviceType ?: $exist['device_type'], strval($protocolVersion), json_encode($config, JSON_UNESCAPED_UNICODE), $now, $sn]);
        $exist['passwd'] = md5($secret);
        $exist['device_type'] = $deviceType ?: $exist['device_type'];
        $exist['protocol_version'] = strval($protocolVersion);
        $exist['config'] = json_encode($config, JSON_UNESCAPED_UNICODE);
        $exist['update_time'] = $now;
        return $exist;
    }

    $stmt = $pdo->prepare('INSERT INTO checkin_device (store_id, sn, name, device_type, passwd, protocol_version, online_status, status, config, create_time, update_time) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
    $name = '设备-' . substr($sn, -6);
    $stmt->execute([0, $sn, $name, $deviceType ?: 'face', md5($secret), strval($protocolVersion), 0, 1, json_encode($config, JSON_UNESCAPED_UNICODE), $now, $now]);

    return [
        'store_id' => 0,
        'sn' => $sn,
        'name' => $name,
        'device_type' => $deviceType ?: 'face',
        'passwd' => md5($secret),
        'protocol_version' => strval($protocolVersion),
        'online_status' => 0,
        'status' => 1,
        'config' => json_encode($config, JSON_UNESCAPED_UNICODE),
        'create_time' => $now,
        'update_time' => $now,
    ];
}

function updateDeviceLoginTime(PDO $pdo, string $sn): void {
    $now = date('Y-m-d H:i:s');
    $stmt = $pdo->prepare('UPDATE checkin_device SET last_login_time = ?, online_status = 1, update_time = ? WHERE sn = ?');
    $stmt->execute([$now, $now, $sn]);
}

function md5Lower(string $s): string { return strtolower(md5($s)); }

function verifyRegisterSign(string $sn, $timestamp, string $sign): bool {
    // 根据现场设备报文确认注册签名拼接顺序为 sn + timestamp + APP_SECRET
    $exp = md5Lower($sn . strval($timestamp) . APP_SECRET);
    return strtolower($sign) === $exp;
}

function verifyLoginSign(string $sn, string $secret, $timestamp, string $sign): bool {
    $exp = md5Lower($sn . $secret . $timestamp);
    return strtolower($sign) === $exp;
}

function updateDeviceHeartbeat(PDO $pdo, string $sn, ?string $ipAddr, ?string $appVersion): void {
    $now = date('Y-m-d H:i:s');
    $stmt = $pdo->prepare('UPDATE checkin_device SET last_heartbeat_time = ?, online_status = 1, ip_address = COALESCE(?, ip_address), firmware_version = COALESCE(?, firmware_version), update_time = ? WHERE sn = ?');
    $stmt->execute([$now, $ipAddr, $appVersion, $now, $sn]);
}

function getDeviceSecret(PDO $pdo, array $device): ?string {
    // 优先从 config.secret 读取
    if (!empty($device['config'])) {
        $cfg = json_decode($device['config'], true);
        if (is_array($cfg) && !empty($cfg['secret'])) {
            return strval($cfg['secret']);
        }
    }
    // 兼容早期：若 passwd 看起来不像 md5，则作为明文秘钥
    if (!empty($device['passwd']) && strlen($device['passwd']) !== 32) {
        return strval($device['passwd']);
    }
    return null;
}


function getDbConnection(): ?PDO {
    global $pdo;
    return $pdo;
}

function toHex(string $bin): string {
    return strtoupper(implode(' ', str_split(bin2hex($bin), 2)));
}

function readExact($conn, int $len): string {
    if ($len <= 0) return '';

    $buf = '';
    $maxChunkSize = 8192;
    $attempts = 0;
    $maxAttempts = 1000;

    while (strlen($buf) < $len && $attempts < $maxAttempts) {
        $remaining = $len - strlen($buf);
        $chunkSize = min($remaining, $maxChunkSize);

        $chunk = fread($conn, $chunkSize);
        if ($chunk === '' || $chunk === false) {
            break;
        }
        $buf .= $chunk;
        $attempts++;
    }
    return $buf;
}

$server = @stream_socket_server("tcp://{$host}:{$port}", $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN);
if (!$server) {
    fwrite(STDERR, "Failed to bind {$host}:{$port} - {$errno} {$errstr}\n");
    exit(1);
}

logMsg("Simple TCP Server started on tcp://{$host}:{$port}");

// 启动后台心跳处理进程（如果支持）
$lastHeartbeatProcessTime = 0;
$lastWebSocketQueueProcessTime = 0;

while (true) {
    // 定期处理心跳队列（每秒检查一次）
    $now = time();
    if ($now - $lastHeartbeatProcessTime >= 1) {
        $lastHeartbeatProcessTime = $now;
        processHeartbeatQueue();
    }

    // 【改进】定期处理WebSocket消息队列（每秒检查一次）
    if ($now - $lastWebSocketQueueProcessTime >= 1) {
        $lastWebSocketQueueProcessTime = $now;
        processWebSocketQueue();
    }

    // 定期保活MySQL连接
    $pdo = keepMySQLAlive($pdo);

    $conn = @stream_socket_accept($server, -1, $peer);
    if ($conn === false) {
        usleep(10000);
        continue;
    }

    stream_set_timeout($conn, 60);
    logMsg("Accepted connection from {$peer}");

    try {
        while (true) {
            // 快速检查MySQL连接（不阻塞）
            if (!isConnectionAlive($pdo)) {
                logMsg("[MySQL] Connection lost, attempting to reconnect...");
                $pdo = createMySQLConnection();
            }

            // 读取11字节头部
            $hdr = readExact($conn, 11);
            if ($hdr === '' || strlen($hdr) < 11) {
                logMsg("Connection closed or header incomplete from {$peer}");
                break;
            }

            // 解析头部
            $m = substr($hdr, 0, 4);
            $ver = ord($hdr[4]);
            $dt = ord($hdr[5]);
            $cmd = ord($hdr[6]);
            $lenBytes = substr($hdr, 7, 4);

            // 智能字节序检测
            $lenLE = unpack('V', $lenBytes)[1];
            $lenBE = unpack('N', $lenBytes)[1];

            if ($lenBE <= 50 * 1024 * 1024 && $lenLE > 50 * 1024 * 1024) {
                $len = $lenBE;
                $endianness = 'big-endian';
            } else {
                $len = $lenLE;
                $endianness = 'little-endian';
            }

            logMsg("Header from {$peer}: marker={$m}, cmd=0x" . dechex($cmd) . ", len={$len} ({$endianness})");

            // 验证协议标识
            if ($m !== $marker) {
                logMsg("Invalid marker from {$peer}: " . toHex($m));
                break;
            }

            // 读取消息体
            $body = readExact($conn, $len);
            if ($len > 0 && strlen($body) < $len) {
                logMsg("Body incomplete from {$peer}: expected {$len}, got " . strlen($body));
                break;
            }

            // 解析JSON
            $json = null;
            $parsed = null;
            if ($dt === 0x01 && $len > 0) {
                $json = $body;
                $parsed = json_decode($json, true);
            }

            logMsg("Message from {$peer}: " . ($json ?? 'no json'));

            // 处理不同类型的消息
            $respData = handleSimpleMessage($cmd, $parsed, $peer);
            $respJson = json_encode($respData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

            // 响应命令映射
            $cmdMap = [
                0x01 => 0x02, // loginReq -> loginRsp
                0x05 => 0x06, // registerReq -> registerRsp
                0x07 => 0x08, // heartbeatReq -> heartbeatRsp
                0x09 => 0x0A, // getUserReq -> getUserRsp
                0x0B => 0x0C, // allUserReq -> allUserRsp
                0x0D => 0x0E, // snapshotFaceReq -> snapshotFaceRsp
                0x15 => 0x16, // faceFeatureFailReq -> faceFeatureFailRsp
                0x19 => 0x1A  // allCardReq -> allCardRsp
            ];
            $respCmd = $cmdMap[$cmd] ?? ($cmd + 1);

            // 构建响应包（使用相同字节序）
            $respLenBytes = ($endianness === 'big-endian') ? pack('N', strlen($respJson)) : pack('V', strlen($respJson));
            $respHdr = $marker . chr($version) . chr($dtype) . chr($respCmd) . $respLenBytes;
            $respPkt = $respHdr . $respJson;

            // 发送响应
            $written = fwrite($conn, $respPkt);
            //logMsg("Response sent to {$peer}: {$written} bytes, cmd=0x" . dechex($respCmd) . ", json={$respJson}");

            // 保持连接继续处理下一个消息
        }
    } catch (\Throwable $e) {
        logMsg("[ERROR] {$peer}: " . $e->getMessage());
    } finally {
        fclose($conn);
        logMsg("Connection closed: {$peer}");
    }
}

/**
 * 处理简单消息（无数据库依赖）
 */
function handleSimpleMessage(int $cmd, ?array $data, string $peer): array
{
    switch ($cmd) {
        case 0x01: // 登录请求
            return handleSimpleLogin($data, $peer);
        case 0x05: // 注册请求
            return handleSimpleRegister($data, $peer);
        case 0x07: // 心跳请求
            return handleSimpleHeartbeat($data, $peer);
        case 0x09: // 获取单个人员信息
            return handleGetUser($data, $peer);
        case 0x0B: // 获取所有用户ID请求
            return handleSimpleGetAllUsers($data, $peer);
        case 0x0D: // 人脸识别签到记录
            return handleSnapshotFace($data, $peer);
        case 0x15: // 人脸特征提取失败上报
            return handleFaceFeatureFail($data, $peer);
        case 0x19: // 获取所有卡号请求
            return handleAllCard($data, $peer);
        default:
            return [
                'result' => 200,
                'desc' => 'Success',
                'message' => 'unknownRsp',
                'messageId' => strval($data['messageId'] ?? ''),
                'data' => []
            ];
    }
}

function handleSimpleLogin(?array $data, string $peer): array
{
    global $frameworkReady, $pdo;

    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'loginRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $timestamp = strval($data['data']['timestamp'] ?? '');
    $sign = strval($data['data']['sign'] ?? '');
    $messageId = strval($data['messageId'] ?? '');

    logMsg("[LOGIN] Device {$sn} login attempt from {$peer}, messageId: {$messageId}");

    if (!$frameworkReady || !$pdo) {
        return [
            'result' => 500,
            'desc' => '服务未就绪',
            'message' => 'loginRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }

    if ($sn === '' || $timestamp === '' || $sign === '') {
        return [
            'result' => 400,
            'desc' => '参数错误',
            'message' => 'loginRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }

    try {
        $dev = getDeviceBySn($pdo, $sn);
        if (!$dev) {
            logMsg("[LOGIN] Device {$sn} not registered");
            return [
                'result' => 1002,
                'desc' => '设备不存在',
                'message' => 'loginRsp',
                'messageId' => $messageId,
                'data' => []
            ];
        }

        $secret = getDeviceSecret($pdo, $dev);
        if (!$secret) {
            logMsg("[LOGIN] Secret not found for {$sn}, respond 1002 to trigger register");
            return [
                'result' => 1002,
                'desc' => '设备不存在',
                'message' => 'loginRsp',
                'messageId' => $messageId,
                'data' => []
            ];
        }

        $ok = verifyLoginSign($sn, $secret, $timestamp, $sign);
        if (!$ok) {
            logMsg("[LOGIN] Sign verify failed for {$sn}");
            return [
                'result' => 401,
                'desc' => '认证失败',
                'message' => 'loginRsp',
                'messageId' => $messageId,
                'data' => []
            ];
        }

        updateDeviceLoginTime($pdo, $sn);
        logMsg("[LOGIN] Device {$sn} login SUCCESS");
        return [
            'result' => 200,
            'desc' => 'Success',
            'message' => 'loginRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    } catch (\Throwable $e) {
        logMsg('[LOGIN] Error: ' . $e->getMessage());
        return [
            'result' => 500,
            'desc' => '登录失败',
            'message' => 'loginRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }
}

function handleSimpleRegister(?array $data, string $peer): array
{
    global $frameworkReady, $pdo;

    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'registerRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $timestamp = strval($data['data']['timestamp'] ?? '');
    $type = strval($data['data']['type'] ?? '');
    $sign = strval($data['data']['sign'] ?? '');
    $messageId = strval($data['messageId'] ?? '');

    logMsg("[REGISTER] Device {$sn} register attempt from {$peer}, messageId: {$messageId}");

    if (!$frameworkReady || !$pdo) {
        return [
            'result' => 500,
            'desc' => '服务未就绪',
            'message' => 'registerRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }

    if ($sn === '' || $timestamp === '' || $sign === '') {
        return [
            'result' => 400,
            'desc' => '参数错误',
            'message' => 'registerRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }

    // 注册场景：使用 appSecret 校验签名
    if (!verifyRegisterSign($sn, $timestamp, $sign)) {
        logMsg("[REGISTER] Sign verify failed for {$sn}");
        return [
            'result' => 401,
            'desc' => '认证失败',
            'message' => 'registerRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }

    try {
        $secret = generatePasswd(8);
        upsertDeviceOnRegister($pdo, $sn, $type !== '' ? $type : null, $secret, 2);
        logMsg("[REGISTER] Device {$sn} registered/updated successfully");
        return [
            'result' => 200,
            'desc' => 'Success',
            'message' => 'registerRsp',
            'messageId' => $messageId,
            'data' => [
                'passwd' => $secret
            ]
        ];
    } catch (\Throwable $e) {
        logMsg('[REGISTER] Error: ' . $e->getMessage());
        return [
            'result' => 500,
            'desc' => '注册失败',
            'message' => 'registerRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }
}

function handleSimpleHeartbeat(?array $data, string $peer): array
{
    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'heartbeatRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $messageId = strval($data['messageId'] ?? '');
    $peopleCount = intval($data['data']['peopleCount'] ?? 0);
    $capacity = intval($data['data']['capacity'] ?? 0);
    $ipAddr = isset($data['data']['ipAddr']) ? strval($data['data']['ipAddr']) : null;
    $appVersion = isset($data['data']['appVersion']) ? strval($data['data']['appVersion']) : null;

    logMsg("[HEARTBEAT] Device {$sn} heartbeat from {$peer}, messageId: {$messageId}, people: {$peopleCount}/{$capacity}");

    // 【改进】将心跳请求放入异步队列，不在主线程执行数据库操作
    // 这样可以立即返回响应，不会阻塞后续的签到请求
    if ($sn !== '') {
        queueHeartbeatAsync([
            'sn' => $sn,
            'ipAddr' => $ipAddr,
            'appVersion' => $appVersion,
            'timestamp' => time()
        ]);
    }

    return [
        'result' => 200,
        'desc' => 'Success',
        'message' => 'heartbeatRsp',
        'messageId' => $messageId,
        'data' => []
    ];
}

function handleGetUser(?array $data, string $peer): array
{
    global $frameworkReady, $pdo;

    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'getUserRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $messageId = strval($data['messageId'] ?? '');
    $mid = intval($data['data']['mid'] ?? 0);

    logMsg("[GET_USER] Device {$sn} request member {$mid} from {$peer}, messageId: {$messageId}");

    if ($mid <= 0) {
        return [
            'result' => 400,
            'desc' => '参数错误：mid不能为空',
            'message' => 'getUserRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }

    try {
        if ($frameworkReady && $pdo) {
            $stmt = $pdo->prepare("SELECT id, name, phone, img, card_no, id_card, update_time, create_time, status FROM checkin_member WHERE id = ? LIMIT 1");
            $stmt->execute([$mid]);
            $m = $stmt->fetch(PDO::FETCH_ASSOC);

            if (!$m) {
                return [
                    'result' => 2006,
                    'desc' => '人员不存在',
                    'message' => 'getUserRsp',
                    'messageId' => $messageId,
                    'data' => []
                ];
            }

            // 计算updateDate（毫秒时间戳字符串）
            $ts = null;
            if (!empty($m['update_time'])) {
                $ts = strtotime($m['update_time']);
            } elseif (!empty($m['create_time'])) {
                $ts = strtotime($m['create_time']);
            } else {
                $ts = time();
            }
            $updateMs = strval($ts * 1000);

            // 拉取图片并转base64
            $photoBase64 = '';
            $imgUrl = trim(strval($m['img'] ?? ''));
            if ($imgUrl !== '') {
                // 检查是否为本地路径
                if (strpos($imgUrl, 'http') === 0) {
                    // 远程URL，尝试转换为本地路径
                    $localPath = str_replace('http://192.168.8.140:8000/', __DIR__ . '/../../public/', $imgUrl);
                    if (file_exists($localPath)) {
                        logMsg("[IMG] Using local file: {$localPath}");
                        $raw = file_get_contents($localPath);
                        $photoBase64 = imageToDeviceBase64($raw);
                    } else {
                        logMsg("[IMG] Local file not found: {$localPath}, trying remote URL");
                        $photoBase64 = fetchImageBase64FromUrl($imgUrl);
                    }
                } else {
                    // 本地路径
                    $localPath = __DIR__ . '/../../public/' . ltrim($imgUrl, '/');
                    if (file_exists($localPath)) {
                        logMsg("[IMG] Using local file: {$localPath}");
                        $raw = file_get_contents($localPath);
                        $photoBase64 = imageToDeviceBase64($raw);
                    }
                }
            }

            // Log decoded image info for quick diagnostics (mime, bytes, dimensions)
            if ($photoBase64 !== '') {
                $rawLog = base64_decode($photoBase64, true);
                if ($rawLog !== false) {
                    $info = @getimagesizefromstring($rawLog);
                    $mime = is_array($info) && isset($info['mime']) ? $info['mime'] : 'unknown';
                    $wh = is_array($info) ? ($info[0] . 'x' . $info[1]) : 'unknown';
                    //logMsg('[IMG] getUserRsp photo: mime=' . $mime . ', bytes=' . strlen($rawLog) . ', dims=' . $wh);
                } else {
                    //logMsg('[IMG] getUserRsp photo: base64_decode failed');
                }
            }


            $respData = [
                'updateDate' => $updateMs,
                'mid' => intval($m['id']),
                'photo' => $photoBase64,
                'photoName' => $m['id'] . '.jpg',
                'username' => strval($m['name'] ?? ''),
                'cardnum' => strval($m['id_card'] ?? ''),
                'iphone' => strval($m['phone'] ?? ''),
                'passNum' => -1,  // -1 表示不限次数
                'startTime' => '2020-01-01 00:00:00',  // 授权开始时间
                'expireTime' => '2099-12-31 23:59:59',  // 授权到期时间
                'isBlack' => (intval($m['status'] ?? 1) !== 1) ? 1 : 0,
                'passPeriod' => '1111111',  // 一周7天都可通行
                'passTimeSection' => '00:00~23:59'  // 全天24小时可通行
            ];

            // 记录返回的数据（不包括照片）
            $logData = $respData;
            //$logData['photo'] = '[BASE64_DATA_' . strlen($photoBase64) . '_BYTES]';
            logMsg('[GET_USER] Response data: ' . json_encode($logData, JSON_UNESCAPED_UNICODE));

            return [
                'result' => 200,
                'desc' => 'Success',
                'message' => 'getUserRsp',
                'messageId' => $messageId,
                'data' => $respData
            ];
        }

        return [
            'result' => 500,
            'desc' => '服务未就绪',
            'message' => 'getUserRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    } catch (\Throwable $e) {
        logMsg('[GET_USER] Error: ' . $e->getMessage());
        return [
            'result' => 500,
            'desc' => '获取人员失败',
            'message' => 'getUserRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }
}

function handleSimpleGetAllUsers(?array $data, string $peer): array
{
    global $frameworkReady, $pdo;

    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'allUserRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $messageId = strval($data['messageId'] ?? '');

    logMsg("[GET_ALL_USERS] Device {$sn} requesting user list from {$peer}, messageId: {$messageId}");

    try {
        if ($frameworkReady && $pdo) {
            logMsg("[GET_ALL_USERS] Database is ready, querying all enabled members...");

            // 返回全体启用会员（不按门店过滤）
            $stmt = $pdo->query("SELECT id, update_time, create_time FROM checkin_member WHERE status = 1");
            $members = $stmt->fetchAll(PDO::FETCH_ASSOC);

            logMsg("[GET_ALL_USERS] Query result: " . count($members) . " members found");

            $list = [];
            foreach ($members as $member) {
                $ts = null;
                if (!empty($member['update_time'])) {
                    $ts = strtotime($member['update_time']);
                } elseif (!empty($member['create_time'])) {
                    $ts = strtotime($member['create_time']);
                } else {
                    $ts = time();
                }
                $list[] = [
                    'mid' => intval($member['id']),
                    'updateDate' => strval($ts * 1000)
                ];
            }

            logMsg("[GET_ALL_USERS] Final result: " . count($list) . " members (all enabled)");

            return [
                'result' => 200,
                'desc' => 'Success',
                'message' => 'allUserRsp',
                'messageId' => $messageId,
                'data' => $list
            ];
        } else {
            // 框架未就绪，返回空列表
            logMsg("[GET_ALL_USERS] Framework not ready, returning empty list");
            return [
                'result' => 200,
                'desc' => 'Success',
                'message' => 'allUserRsp',
                'messageId' => $messageId,
                'data' => []
            ];
        }
    } catch (\Throwable $e) {
        logMsg("[GET_ALL_USERS] Error: " . $e->getMessage());
        return [
            'result' => 500,
            'desc' => '获取用户列表失败',
            'message' => 'allUserRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }
}

/**
 * 处理人脸特征提取失败上报 (0x15)
 */
function handleFaceFeatureFail(?array $data, string $peer): array
{
    global $frameworkReady, $pdo;

    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'faceFeatureFailRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $messageId = strval($data['messageId'] ?? '');
    $failList = $data['data'] ?? [];

    logMsg("[FACE_FEATURE_FAIL] Device {$sn} from {$peer}, messageId: {$messageId}");

    // 记录失败信息
    if (is_array($failList)) {
        foreach ($failList as $item) {
            $mid = intval($item['mid'] ?? 0);
            $type = intval($item['type'] ?? 0);

            // 错误类型描述（按文档定义）
            switch ($type) {
                case 0:
                    $typeDesc = '正常';
                    break;
                case 1:
                    $typeDesc = '图片过大';
                    break;
                case 2:
                    $typeDesc = '无法提特征';
                    break;
                case 3:
                    $typeDesc = '脸部姿态检测异常';
                    break;
                case 4:
                    $typeDesc = '图片为空';
                    break;
                case 9:
                    $typeDesc = '超时';
                    break;
                default:
                    $typeDesc = "未知错误(type={$type})";
                    break;
            }

            logMsg("[FACE_FEATURE_FAIL] mid={$mid}, type={$type} ({$typeDesc})");

            // 可以在这里记录到数据库，方便排查问题
            if ($frameworkReady && $pdo && $mid > 0) {
                try {
                    $stmt = $pdo->prepare("INSERT INTO checkin_device_log (device_sn, member_id, event_type, event_data, create_time) VALUES (?, ?, ?, ?, NOW())");
                    $logContent = json_encode(['type' => $type, 'desc' => $typeDesc], JSON_UNESCAPED_UNICODE);
                    $stmt->execute([$sn, $mid, 'face_feature_fail', $logContent]);
                } catch (\Throwable $e) {
                    logMsg("[FACE_FEATURE_FAIL] Failed to log to database: " . $e->getMessage());
                }
            }
        }
    }

    return [
        'result' => 200,
        'desc' => 'Success',
        'message' => 'faceFeatureFailRsp',
        'messageId' => $messageId,
        'data' => []
    ];
}

/**
 * 处理获取所有卡号请求 (0x19)
 */
function handleAllCard(?array $data, string $peer): array
{
    global $frameworkReady, $pdo;

    if (!$data) {
        return [
            'result' => 400,
            'desc' => '请求数据格式错误',
            'message' => 'allCardRsp',
            'messageId' => strval($data['messageId'] ?? ''),
            'data' => []
        ];
    }

    $sn = strval($data['sn'] ?? '');
    $messageId = strval($data['messageId'] ?? '');

    logMsg("[ALL_CARD] Device {$sn} requesting card list from {$peer}, messageId: {$messageId}");

    try {
        if ($frameworkReady && $pdo) {
            // 查询所有启用会员的卡号
            $stmt = $pdo->query("SELECT id, card_no, update_time, create_time FROM checkin_member WHERE status = 1 AND card_no IS NOT NULL AND card_no != ''");
            $members = $stmt->fetchAll(PDO::FETCH_ASSOC);

            logMsg("[ALL_CARD] Query result: " . count($members) . " cards found");

            $list = [];
            foreach ($members as $member) {
                $ts = null;
                if (!empty($member['update_time'])) {
                    $ts = strtotime($member['update_time']);
                } elseif (!empty($member['create_time'])) {
                    $ts = strtotime($member['create_time']);
                } else {
                    $ts = time();
                }

                $list[] = [
                    'cardNo' => strval($member['card_no']),
                    'mid' => intval($member['id'])
                ];
            }

            logMsg("[ALL_CARD] Final result: " . count($list) . " cards");

            return [
                'result' => 200,
                'desc' => 'Success',
                'message' => 'allCardRsp',
                'messageId' => $messageId,
                'data' => $list
            ];
        } else {
            // 框架未就绪，返回空列表
            logMsg("[ALL_CARD] Framework not ready, returning empty list");
            return [
                'result' => 200,
                'desc' => 'Success',
                'message' => 'allCardRsp',
                'messageId' => $messageId,
                'data' => []
            ];
        }
    } catch (\Throwable $e) {
        logMsg("[ALL_CARD] Error: " . $e->getMessage());
        return [
            'result' => 500,
            'desc' => '获取卡号列表失败',
            'message' => 'allCardRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }
}

/**
 * 从完整URL拉取图片并返回base64（无 data: 头）
 */
function fetchImageBase64FromUrl(string $url, int $timeoutSec = 5, int $maxBytes = 2097152): string
{
    try {
        logMsg("[IMG] Fetching image from: {$url}");

        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeoutSec);
            curl_setopt($ch, CURLOPT_TIMEOUT, $timeoutSec);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
            $data = curl_exec($ch);
            $err  = curl_error($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            if ($data === false || $code >= 400) {
                logMsg('[IMG] curl fetch failed: ' . ($err ?: ('HTTP ' . $code)));
                return '';
            }
            if (strlen($data) > $maxBytes) {
                logMsg('[IMG] image too large: ' . strlen($data) . ' bytes');
                return '';
            }
            logMsg('[IMG] Successfully fetched ' . strlen($data) . ' bytes');
            return imageToDeviceBase64($data);
        }

        $ctx = stream_context_create([
            'http' => ['timeout' => $timeoutSec],
            'https' => ['timeout' => $timeoutSec],
        ]);
        $data = @file_get_contents($url, false, $ctx);
        if ($data === false) {
            logMsg('[IMG] file_get_contents failed');
            return '';
        }
        if (strlen($data) > $maxBytes) {
            logMsg('[IMG] image too large: ' . strlen($data) . ' bytes');
            return '';
        }
        logMsg('[IMG] Successfully fetched ' . strlen($data) . ' bytes');
        return imageToDeviceBase64($data);
    } catch (\Throwable $e) {
        logMsg('[IMG] fetch error: ' . $e->getMessage());
        return '';
    }
}

/**
 * 从完整URL拉取图片原始二进制（不做任何转换）
 */
function fetchImageRaw(string $url, int $timeoutSec = 5, int $maxBytes = 2097152): ?string
{
    try {
        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeoutSec);
            curl_setopt($ch, CURLOPT_TIMEOUT, $timeoutSec);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
            $data = curl_exec($ch);
            $err  = curl_error($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($data === false || $code >= 400) {
                logMsg('[IMG] fetchImageRaw curl failed: ' . ($err ?: ('HTTP ' . $code)));
                return null;
            }
            if (strlen($data) > $maxBytes) {
                logMsg('[IMG] fetchImageRaw too large: ' . strlen($data) . ' bytes');
                return null;
            }
            logMsg('[IMG] fetchImageRaw OK: ' . strlen($data) . ' bytes');
            return $data;
        }
        $ctx = stream_context_create([
            'http' => ['timeout' => $timeoutSec],
            'https' => ['timeout' => $timeoutSec],
        ]);
        $data = @file_get_contents($url, false, $ctx);
        if ($data === false) {
            logMsg('[IMG] fetchImageRaw file_get_contents failed');
            return null;
        }
        if (strlen($data) > $maxBytes) {
            logMsg('[IMG] fetchImageRaw too large: ' . strlen($data) . ' bytes');
            return null;
        }
        logMsg('[IMG] fetchImageRaw OK: ' . strlen($data) . ' bytes');
        return $data;
    } catch (\Throwable $e) {
        logMsg('[IMG] fetchImageRaw error: ' . $e->getMessage());
        return null;
    }
}

/**
 * Letterbox 适配：保持原始长宽比，内贴到正方形画布（白底），避免中心裁剪导致的人脸被截断
 * 输出基线 JPEG，默认 600x600，质量 90
 */
function imageToDeviceBase64Letterbox(string $raw, int $targetSize = 600, int $quality = 90): string
{
    if ($raw === '') return '';
    if (!function_exists('imagecreatefromstring')) {
        return base64_encode($raw);
    }
    try {
        $src = @imagecreatefromstring($raw);
        if (!$src) return base64_encode($raw);
        $w = imagesx($src); $h = imagesy($src);
        if ($w <= 0 || $h <= 0) { imagedestroy($src); return base64_encode($raw); }
        $scale = min($targetSize / $w, $targetSize / $h);
        $nw = max(1, (int)floor($w * $scale));
        $nh = max(1, (int)floor($h * $scale));
        $dx = (int)floor(($targetSize - $nw) / 2);
        $dy = (int)floor(($targetSize - $nh) / 2);
        $dst = imagecreatetruecolor($targetSize, $targetSize);
        $white = imagecolorallocate($dst, 255, 255, 255);
        imagefill($dst, 0, 0, $white);
        imagecopyresampled($dst, $src, $dx, $dy, 0, 0, $nw, $nh, $w, $h);
        ob_start();
        imagejpeg($dst, null, $quality);
        $out = ob_get_clean();
        imagedestroy($src); imagedestroy($dst);
        if ($out === false || $out === '') return base64_encode($raw);
        return base64_encode($out);
    } catch (\Throwable $e) {
        return base64_encode($raw);
    }
}



/**
 * 将任意图片二进制规范化为设备友好的 JPEG，并返回 base64（无 data: 头）
 * 处理：中心裁剪为正方形 -> 缩放到 400x400 -> JPEG(85)
 */
function imageToDeviceBase64(string $raw, int $targetSize = 400): string
{
    if ($raw === '') return '';
    if (!function_exists('imagecreatefromstring')) {
        return base64_encode($raw);
    }
    try {
        $src = @imagecreatefromstring($raw);
        if (!$src) return base64_encode($raw);
        $w = imagesx($src); $h = imagesy($src);
        if ($w <= 0 || $h <= 0) { imagedestroy($src); return base64_encode($raw); }
        $side = min($w, $h);
        $sx = (int)(($w - $side) / 2);
        $sy = (int)(($h - $side) / 2);
        $dst = imagecreatetruecolor($targetSize, $targetSize);
        // 白底避免透明背景
        $white = imagecolorallocate($dst, 255, 255, 255);
        imagefill($dst, 0, 0, $white);
        imagecopyresampled($dst, $src, 0, 0, $sx, $sy, $targetSize, $targetSize, $side, $side);
        ob_start();
        imagejpeg($dst, null, 85);
        $out = ob_get_clean();
        imagedestroy($src); imagedestroy($dst);
        if ($out === false || $out === '') return base64_encode($raw);
        return base64_encode($out);
    } catch (\Throwable $e) {
        return base64_encode($raw);
    }
}


/**
 * 处理人脸识别签到记录 (0x0D) - 带积分计算
 */
function handleSnapshotFace(?array $data, string $peer): array
{
    $messageId = strval($data['messageId'] ?? '');
    $sn = strval($data['sn'] ?? '');
    $faceData = $data['data'] ?? [];

    logMsg("[SNAPSHOT_FACE] Device {$sn} face recognition from {$peer}, messageId: {$messageId}");

    try {
        // 解析识别数据
        $deviceId = intval($faceData['deviceId'] ?? 0);
        $isSuccess = intval($faceData['isSuccess'] ?? 0);
        $isStranger = intval($faceData['isStranger'] ?? 1);
        $isTemp = intval($faceData['isTemp'] ?? 0);
        $mask = intval($faceData['mask'] ?? 0);
        $photo = strval($faceData['photo'] ?? '');
        $cardNum = strval($faceData['cardNum'] ?? '');
        $address = strval($faceData['address'] ?? '');
        $birthday = strval($faceData['birthday'] ?? '');
        $nation = strval($faceData['nation'] ?? '');
        $mjkh = strval($faceData['mjkh'] ?? '');
        $healthCodeStatus = strval($faceData['healthCodeStatus'] ?? '');
        $invalidReason = strval($faceData['invalidReason'] ?? '');
        $isOver = intval($faceData['isOver'] ?? 0);
        $unlockType = intval($faceData['unlockType'] ?? 0);
        $userId = intval($faceData['userId'] ?? 0);

        logMsg("[SNAPSHOT_FACE] Recognition result - Success: {$isSuccess}, Stranger: {$isStranger}, Card: {$cardNum}, MJKH: {$mjkh}, UnlockType: {$unlockType}, UserId: {$userId}");

        // 获取数据库连接
        $pdo = getDbConnection();
        if (!$pdo) {
            logMsg("[SNAPSHOT_FACE] Database connection failed");
            return [
                'result' => 500,
                'desc' => 'Database Error',
                'message' => 'snapshotFaceRsp',
                'messageId' => $messageId,
                'data' => []
            ];
        }

        // 记录签到日志
        $now = date('Y-m-d H:i:s');
        $checkTime = $now;

        // 如果识别成功且不是陌生人，尝试匹配用户
        $memberId = 0;
        $memberName = '';
        $memberCardNo = '';

        if ($isSuccess && !$isStranger) {
            $member = null;
            if ($unlockType === 4 && !empty($cardNum)) {
                // 身份证刷卡访客：按身份证号匹配
                $stmt = $pdo->prepare("SELECT id, name, card_no FROM checkin_member WHERE id_card = ? AND status = 1");
                $stmt->execute([$cardNum]);
                $member = $stmt->fetch(PDO::FETCH_ASSOC);
            } elseif (!empty($mjkh)) {
                // 门禁卡号：按卡号匹配
                $stmt = $pdo->prepare("SELECT id, name, card_no FROM checkin_member WHERE card_no = ? AND status = 1");
                $stmt->execute([$mjkh]);
                $member = $stmt->fetch(PDO::FETCH_ASSOC);
            } elseif ($userId > 0) {
                // 设备上报用户ID：按会员ID匹配
                $stmt = $pdo->prepare("SELECT id, name, card_no FROM checkin_member WHERE id = ? AND status = 1");
                $stmt->execute([$userId]);
                $member = $stmt->fetch(PDO::FETCH_ASSOC);
            }

            if ($member) {
                $memberId = intval($member['id']);
                $memberName = strval($member['name']);
                $memberCardNo = strval($member['card_no'] ?? '');
                logMsg("[SNAPSHOT_FACE] Found member: ID={$memberId}, Name={$memberName}, CardNo={$memberCardNo}");
            }
        }

        // 【注释】保存签到日志到 system_checkin_log（已注释，不再保存）
        // $stmt = $pdo->prepare("
        //     INSERT INTO system_checkin_log (
        //         device_sn, member_id, member_name, card_num, check_time,
        //         is_success, is_stranger, is_temp, mask_status,
        //         address, birthday, nation, mjkh, health_code_status,
        //         invalid_reason, is_over, create_time
        //     ) VALUES (
        //         ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        //     )
        // ");
        //
        // $result = $stmt->execute([
        //     $sn, $memberId, $memberName, $cardNum, $checkTime,
        //     $isSuccess, $isStranger, $isTemp, $mask,
        //     $address, $birthday, $nation, $mjkh, $healthCodeStatus,
        //     $invalidReason, $isOver, $now
        // ]);
        //
        // if (!$result) {
        //     logMsg("[SNAPSHOT_FACE] Failed to save checkin log");
        //     return [
        //         'result' => 500,
        //         'desc' => 'Save Failed',
        //         'message' => 'snapshotFaceRsp',
        //         'messageId' => $messageId,
        //         'data' => []
        //     ];
        // }
        //
        // $logId = $pdo->lastInsertId();
        // logMsg("[SNAPSHOT_FACE] Checkin log saved successfully, ID: {$logId}");

        $logId = 0; // 占位符，保持代码兼容性

        // 如果是成功识别的会员，处理签到积分逻辑
        $responseData = [];
        if ($memberId > 0 && $isSuccess) {
            $checkinResult = processCheckinPoints($pdo, $memberId, $memberName, $memberCardNo, $sn, $checkTime);
            $responseData = $checkinResult['data'];
            logMsg("[SNAPSHOT_FACE] Checkin processing result: " . json_encode($checkinResult, JSON_UNESCAPED_UNICODE));
        }

        return [
            'result' => 200,
            'desc' => 'Success',
            'message' => 'snapshotFaceRsp',
            'messageId' => $messageId,
            'data' => $responseData
        ];

    } catch (\Throwable $e) {
        logMsg("[SNAPSHOT_FACE] Error processing face recognition: " . $e->getMessage());
        return [
            'result' => 500,
            'desc' => 'Processing Error',
            'message' => 'snapshotFaceRsp',
            'messageId' => $messageId,
            'data' => []
        ];
    }
}

/**
 * 处理签到积分逻辑
 * @param PDO $pdo 数据库连接
 * @param int $memberId 会员ID
 * @param string $memberName 会员姓名
 * @param string $cardNo 卡号
 * @param string $deviceSn 设备序列号
 * @param string $checkTime 签到时间
 * @return array 处理结果
 */
function processCheckinPoints($pdo, int $memberId, string $memberName, string $cardNo, string $deviceSn, string $checkTime): array
{
    try {
        // 【性能监控】记录开始时间
        $perfStart = microtime(true);

        // 【改进】防重复刷脸检查（30秒内的重复刷脸忽略）
        // 使用 Redis 缓存而不是查询数据库，避免大表查询延迟
        $t1 = microtime(true);
        $isDuplicate = checkDuplicateScanWithRedis($memberId, $checkTime);
        $t1Elapsed = round((microtime(true) - $t1) * 1000, 2);

        if ($isDuplicate) {
            logMsg("[CHECKIN] Duplicate scan detected for member {$memberId}, ignored (duplicate check: {$t1Elapsed}ms)");
            // 播放重复刷脸提示
            playVoice('duplicate', [
                'member_id' => $memberId,
                'member_name' => $memberName,
                'card_no' => $cardNo,
                'interval' => 0
            ]);
            return [
                'success' => false,
                'message' => '请勿重复刷脸',
                'data' => [
                    'action' => 'duplicate',
                    'interval' => 0,
                    'voice_message' => '请勿重复刷脸'
                ]
            ];
        }

        // 2. 获取设备所属门店
        $t2 = microtime(true);
        $stmt = $pdo->prepare("SELECT store_id FROM checkin_device WHERE sn = ?");
        $stmt->execute([$deviceSn]);
        $device = $stmt->fetch(PDO::FETCH_ASSOC);
        $storeId = $device ? intval($device['store_id']) : 0;
        $t2Elapsed = round((microtime(true) - $t2) * 1000, 2);

        // 3. 【优化】从Redis查找进行中的签到记录（不查询数据库）
        $t3 = microtime(true);
        $redis = getRedisConnection();
        $ongoingRecordData = null;

        if ($redis) {
            // 从Redis获取进行中的签到记录信息
            $cacheKey = "checkin:ongoing:{$memberId}";
            $cachedData = $redis->get($cacheKey);

            if ($cachedData !== false) {
                // Redis中有记录，解析JSON
                $ongoingRecordData = json_decode($cachedData, true);
                logMsg("[CHECKIN] Found ongoing record in Redis for member {$memberId}");
            }
        } else {
            // Redis不可用，降级到数据库查询
            logMsg("[CHECKIN] Redis unavailable, fallback to database query");
            $stmt = $pdo->prepare("
                SELECT id, start_time, store_id
                FROM checkin_record
                WHERE member_id = ? AND status = 3
                ORDER BY start_time DESC
                LIMIT 1
            ");
            $stmt->execute([$memberId]);
            $ongoingRecordData = $stmt->fetch(PDO::FETCH_ASSOC);
        }

        $t3Elapsed = round((microtime(true) - $t3) * 1000, 2);

        // 【性能监控】记录总耗时
        $totalElapsed = round((microtime(true) - $perfStart) * 1000, 2);
        logMsg("[CHECKIN] Performance - duplicate:{$t1Elapsed}ms, device:{$t2Elapsed}ms, record:{$t3Elapsed}ms, total:{$totalElapsed}ms");

        if ($ongoingRecordData) {
            // 有进行中的记录 -> 结束签到
            return endCheckin($pdo, $memberId, $memberName, $cardNo, $ongoingRecordData, $checkTime);
        } else {
            // 没有进行中的记录 -> 开始签到
            return startCheckin($pdo, $memberId, $memberName, $cardNo, $storeId, $checkTime);
        }

    } catch (\Throwable $e) {
        logMsg("[CHECKIN] Error: " . $e->getMessage());
        return [
            'success' => false,
            'message' => '处理失败',
            'data' => []
        ];
    }
}

/**
 * 开始签到
 */
function startCheckin($pdo, int $memberId, string $memberName, string $cardNo, int $storeId, string $checkTime): array
{
    try {
        // 1. 立即播放语音：签到成功（优先级最高）
        playVoice('success', [
            'member_id' => $memberId,
            'member_name' => $memberName,
            'card_no' => $cardNo,
            'action' => 'start',
            'start_time' => $checkTime
        ]);

        // 2. 查询会员当前积分，保存到Redis缓存（用于签到结束时使用）
        $stmt = $pdo->prepare("SELECT total_points, available_points FROM checkin_member WHERE id = ?");
        $stmt->execute([$memberId]);
        $member = $stmt->fetch(PDO::FETCH_ASSOC);
        $totalPoints = intval($member['total_points'] ?? 0);
        $availablePoints = intval($member['available_points'] ?? 0);

        saveMemberToCache($memberId, [
            'id' => $memberId,
            'name' => $memberName,
            'card_no' => $cardNo,
            'store_id' => $storeId,
            'total_points' => $totalPoints,
            'available_points' => $availablePoints
        ]);

        // 3. 创建签到记录
        $stmt = $pdo->prepare("
            INSERT INTO checkin_record (
                member_id, store_id, card_no, start_time,
                status, create_time
            ) VALUES (?, ?, ?, ?, 3, ?)
        ");

        $result = $stmt->execute([$memberId, $storeId, $cardNo, $checkTime, $checkTime]);

        if ($result) {
            $recordId = $pdo->lastInsertId();
            logMsg("[CHECKIN] Start checkin for member {$memberId} ({$memberName}), record ID: {$recordId}");

            // 4. 【优化】将进行中的签到记录存入Redis（24小时过期）
            $redis = getRedisConnection();
            if ($redis) {
                $cacheKey = "checkin:ongoing:{$memberId}";
                $recordData = json_encode([
                    'id' => $recordId,
                    'start_time' => $checkTime,
                    'store_id' => $storeId
                ], JSON_UNESCAPED_UNICODE);
                $redis->setex($cacheKey, 86400, $recordData); // 24小时过期
                logMsg("[CHECKIN] Saved ongoing record to Redis: {$cacheKey}");
            }

            // 5. 更新会员最后签到时间
            $stmt = $pdo->prepare("UPDATE checkin_member SET last_checkin_time = ? WHERE id = ?");
            $stmt->execute([$checkTime, $memberId]);

            return [
                'success' => true,
                'message' => '签到开始',
                'data' => [
                    'action' => 'start',
                    'record_id' => $recordId,
                    'member_name' => $memberName,
                    'start_time' => $checkTime,
                    'voice_message' => '签到成功'
                ]
            ];
        } else {
            logMsg("[CHECKIN] Failed to create checkin record for member {$memberId}");
            return [
                'success' => false,
                'message' => '签到失败',
                'data' => []
            ];
        }

    } catch (\Throwable $e) {
        logMsg("[CHECKIN] Start checkin error: " . $e->getMessage());
        return [
            'success' => false,
            'message' => '签到失败',
            'data' => []
        ];
    }
}

/**
 * 结束签到并计算积分
 */
function endCheckin($pdo, int $memberId, string $memberName, string $cardNo, array $ongoingRecord, string $endTime): array
{
    try {
        $recordId = intval($ongoingRecord['id']);
        $startTime = $ongoingRecord['start_time'];
        $storeId = intval($ongoingRecord['store_id']);

        // 计算时长（分钟）
        $startTimestamp = strtotime($startTime);
        $endTimestamp = strtotime($endTime);
        $durationMinutes = intval(($endTimestamp - $startTimestamp) / 60);

        logMsg("[CHECKIN] End checkin for member {$memberId} ({$memberName}), duration: {$durationMinutes} minutes");

        // 获取系统配置
        $stmt = $pdo->prepare("SELECT * FROM checkin_setting WHERE store_id = 0 LIMIT 1");
        $stmt->execute();
        $config = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$config) {
            logMsg("[CHECKIN] System config not found, using defaults");
            $config = [
                'unit_minutes' => 10,
                'unit_points' => 1,
                'rounding' => 'floor',
                'timeout_minutes' => 0,
                'time_slots' => '',
                'points_expire_days' => 0
            ];
        }

        $timeoutMinutes = intval($config['timeout_minutes']);
        $unitMinutes = intval($config['unit_minutes']);
        $unitPoints = intval($config['unit_points'] ?? 1);
        $rounding = strval($config['rounding']);
        $timeSlotsJson = strval($config['time_slots'] ?? '');
        $expireDays = intval($config['points_expire_days']);

        // 超时自动开启新签到配置（写死，默认0）
        // 0 = 只作废，1 = 作废后自动开启新签到
        $timeoutAutoStart = 1; // TODO: 后期可改为从配置读取

        // 检查是否超时作废
        if ($timeoutMinutes > 0 && $durationMinutes > $timeoutMinutes) {
            // 超时作废
            $stmt = $pdo->prepare("
                UPDATE checkin_record
                SET end_time = ?, duration_minutes = ?, points = 0,
                    status = 2, invalid_reason = ?
                WHERE id = ?
            ");
            $invalidReason = "超时作废（超过{$timeoutMinutes}分钟）";
            $stmt->execute([$endTime, $durationMinutes, $invalidReason, $recordId]);

            logMsg("[CHECKIN] Checkin timeout, duration: {$durationMinutes} > {$timeoutMinutes}, marked as invalid");

            // 清除Redis缓存
            deleteMemberFromCache($memberId);

            // 【优化】删除进行中的签到记录缓存
            $redis = getRedisConnection();
            if ($redis) {
                $cacheKey = "checkin:ongoing:{$memberId}";
                $redis->del($cacheKey);
                logMsg("[CHECKIN] Deleted ongoing record from Redis (timeout): {$cacheKey}");
            }

            // 如果配置为自动开启新签到
            if ($timeoutAutoStart == 1) {
                logMsg("[CHECKIN] Auto-start new checkin after timeout (timeout_auto_start=1)");

                // 自动开启新签到（使用当前刷脸时间作为开始时间）
                $newCheckinResult = startCheckin($pdo, $memberId, $memberName, $cardNo, $storeId, $endTime);

                if ($newCheckinResult['success']) {
                    logMsg("[CHECKIN] Auto-started new checkin, record ID: " . $newCheckinResult['data']['record_id']);

                    return [
                        'success' => true,
                        'message' => '上次签到超时作废，已自动开启新签到',
                        'data' => [
                            'action' => 'timeout_restart',
                            'old_record_id' => $recordId,
                            'old_duration_minutes' => $durationMinutes,
                            'old_invalid_reason' => $invalidReason,
                            'new_record_id' => $newCheckinResult['data']['record_id'],
                            'new_start_time' => $endTime,
                            'member_name' => $memberName,
                            'voice_message' => '签到成功'
                        ]
                    ];
                } else {
                    logMsg("[CHECKIN] Failed to auto-start new checkin: " . $newCheckinResult['message']);
                }
            }

            return [
                'success' => true,
                'message' => '签到超时作废',
                'data' => [
                    'action' => 'end',
                    'status' => 'invalid',
                    'record_id' => $recordId,
                    'member_name' => $memberName,
                    'duration_minutes' => $durationMinutes,
                    'points' => 0,
                    'reason' => $invalidReason,
                    'voice_message' => '结束签到'
                ]
            ];
        }

        // 计算积分
        $points = calculatePoints($durationMinutes, $startTime, $endTime, $unitMinutes, $unitPoints, $rounding, $timeSlotsJson);

        logMsg("[CHECKIN] Calculated points: {$points} for duration: {$durationMinutes} minutes");

        // 1. 从 Redis 缓存获取会员当前积分（无需查询数据库）
        $memberData = getMemberFromCache($memberId);
        if (!$memberData) {
            // 如果缓存不存在，从数据库查询（备用方案）
            logMsg("[CHECKIN] Member data not found in cache, querying database");
            $stmt = $pdo->prepare("SELECT total_points, available_points FROM checkin_member WHERE id = ?");
            $stmt->execute([$memberId]);
            $member = $stmt->fetch(PDO::FETCH_ASSOC);
            $totalPoints = intval($member['total_points'] ?? 0);
            $availablePoints = intval($member['available_points'] ?? 0);
        } else {
            // 从缓存获取当前积分
            $totalPoints = intval($memberData['total_points'] ?? 0);
            $availablePoints = intval($memberData['available_points'] ?? 0);
            logMsg("[CHECKIN] Member total_points from cache: {$totalPoints}, available_points: {$availablePoints}");
        }

        // 加上本次积分后的总积分和可用积分
        $totalPointsAfter = $totalPoints + $points;
        $availablePointsAfter = $availablePoints + $points;

        // 2. 立即播放语音：结束签到（优先级最高，不等待数据库操作）
        playVoice('end', [
            'member_id' => $memberId,
            'member_name' => $memberName,
            'card_no' => $cardNo,
            'action' => 'end',
            'record_id' => $recordId,
            'duration_minutes' => $durationMinutes,
            'points' => $points,
            'total_points' => $totalPointsAfter,
            'available_points' => $availablePointsAfter,
            'start_time' => $startTime,
            'end_time' => $endTime
        ]);

        // 3. 开始事务（播报语音后再执行数据库操作）
        $pdo->beginTransaction();

        try {
            // 更新签到记录
            $stmt = $pdo->prepare("
                UPDATE checkin_record
                SET end_time = ?, duration_minutes = ?, points = ?, status = 1
                WHERE id = ?
            ");
            $stmt->execute([$endTime, $durationMinutes, $points, $recordId]);

            // 更新会员积分
            if ($points > 0) {
                $stmt = $pdo->prepare("
                    UPDATE checkin_member
                    SET total_points = total_points + ?,
                        available_points = available_points + ?
                    WHERE id = ?
                ");
                $stmt->execute([$points, $points, $memberId]);

                // 记录积分明细
                $expireTime = null;
                if ($expireDays > 0) {
                    $expireTime = date('Y-m-d H:i:s', strtotime("+{$expireDays} days"));
                }

                $stmt = $pdo->prepare("
                    INSERT INTO checkin_points_record (
                        member_id, store_id, points, type, source, related_id,
                        expire_time, status, remark, create_time
                    ) VALUES (?, ?, ?, 1, 'checkin', ?, ?, 1, ?, ?)
                ");
                $remark = "打卡{$durationMinutes}分钟";
                $stmt->execute([
                    $memberId, $storeId, $points, $recordId,
                    $expireTime, $remark, $endTime
                ]);

                logMsg("[CHECKIN] Points added: {$points} for member {$memberId}");
            }

            $pdo->commit();

            // 4. 事务提交后，清除Redis缓存
            deleteMemberFromCache($memberId);

            // 5. 【优化】删除进行中的签到记录缓存
            $redis = getRedisConnection();
            if ($redis) {
                $cacheKey = "checkin:ongoing:{$memberId}";
                $redis->del($cacheKey);
                logMsg("[CHECKIN] Deleted ongoing record from Redis: {$cacheKey}");
            }

            return [
                'success' => true,
                'message' => '签到结束',
                'data' => [
                    'action' => 'end',
                    'status' => 'success',
                    'record_id' => $recordId,
                    'member_name' => $memberName,
                    'duration_minutes' => $durationMinutes,
                    'points' => $points,
                    'start_time' => $startTime,
                    'end_time' => $endTime,
                    'voice_message' => '结束签到'
                ]
            ];

        } catch (\Throwable $e) {
            $pdo->rollBack();
            logMsg("[CHECKIN] Transaction failed: " . $e->getMessage());
            throw $e;
        }

    } catch (\Throwable $e) {
        logMsg("[CHECKIN] End checkin error: " . $e->getMessage());
        return [
            'success' => false,
            'message' => '签到结束失败',
            'data' => []
        ];
    }
}

/**
 * 计算积分
 *
 * @param int $durationMinutes 签到时长（分钟）
 * @param string $startTime 开始时间
 * @param string $endTime 结束时间
 * @param int $unitMinutes 每满N分钟为1个单位
 * @param int $unitPoints 每个单位记多少分（通用积分）
 * @param string $rounding 取整方式
 * @param string $timeSlotsJson 时间段配置JSON
 * @return int 积分
 */
function calculatePoints(int $durationMinutes, string $startTime, string $endTime, int $unitMinutes, int $unitPoints, string $rounding, string $timeSlotsJson): int
{
    logMsg("[CHECKIN] calculatePoints called - duration: {$durationMinutes}, startTime: {$startTime}, endTime: {$endTime}, unitMinutes: {$unitMinutes}, unitPoints: {$unitPoints}");
    logMsg("[CHECKIN] timeSlotsJson: " . $timeSlotsJson);

    // 如果有时间段配置，优先使用时间段规则
    if (!empty($timeSlotsJson)) {
        $timeSlots = json_decode($timeSlotsJson, true);
        logMsg("[CHECKIN] Decoded timeSlots: " . json_encode($timeSlots, JSON_UNESCAPED_UNICODE));

        if (is_array($timeSlots) && count($timeSlots) > 0) {
            logMsg("[CHECKIN] Using time slots rule, slots count: " . count($timeSlots));
            return calculatePointsByTimeSlots($durationMinutes, $startTime, $endTime, $timeSlots, $unitMinutes, $unitPoints, $rounding);
        } else {
            logMsg("[CHECKIN] timeSlots is not valid array or empty, using base rule");
        }
    } else {
        logMsg("[CHECKIN] timeSlotsJson is empty, using base rule");
    }

    // 使用基础规则：floor(时长 / 单位分钟) × 单位积分
    if ($unitMinutes <= 0) $unitMinutes = 10;
    if ($unitPoints <= 0) $unitPoints = 1;

    // 计算单位数（向下取整，不满1个单位不计算）
    $units = intval(floor($durationMinutes / $unitMinutes));

    // 计算总积分
    $totalPoints = $units * $unitPoints;

    logMsg("[CHECKIN] Base rule: {$durationMinutes} minutes / {$unitMinutes} = {$units} units × {$unitPoints} points = {$totalPoints} points");

    return $totalPoints;
}

/**
 * 按时间段计算积分（改进版：按时间段拆分计算）
 *
 * 规则说明：
 * 1. 将签到时长按时间段拆分，每个时间段内的时长使用该时间段的积分规则
 * 2. 不在任何时间段内的时长，使用通用积分
 * 3. 时间段边界不包含结束时间（左闭右开区间 [start, end)）
 *
 * 计算公式：
 * - 每个时间段：单位数 = floor(该时间段内的分钟数 / 单位分钟)
 * - 积分 = 单位数 × 每单位积分
 *
 * 示例：
 * - 7:50-10:50签到，时间段配置：8:00-9:00(2分)，9:00-10:00(3分)，通用积分1分，每10分钟1单位
 * - 7:50-8:00 (10分钟) → 1单位 × 1分 = 1分
 * - 8:00-9:00 (60分钟) → 6单位 × 2分 = 12分
 * - 9:00-10:00 (60分钟) → 6单位 × 3分 = 18分
 * - 10:00-10:50 (50分钟) → 5单位 × 1分 = 5分
 * - 总计：36分
 *
 * @param int $durationMinutes 签到时长（分钟）
 * @param string $startTime 开始时间
 * @param string $endTime 结束时间
 * @param array $timeSlots 时间段配置
 * @param int $unitMinutes 单位分钟数
 * @param int $unitPoints 通用积分（每单位记多少分）
 * @param string $rounding 取整方式（已废弃，固定使用floor）
 * @return int 积分
 */
function calculatePointsByTimeSlots(int $durationMinutes, string $startTime, string $endTime, array $timeSlots, int $unitMinutes, int $unitPoints, string $rounding): int
{
    logMsg("[CHECKIN] calculatePointsByTimeSlots called - duration: {$durationMinutes}, startTime: {$startTime}, endTime: {$endTime}");
    logMsg("[CHECKIN] Parameters - unitMinutes: {$unitMinutes}, unitPoints: {$unitPoints}, timeSlots: " . json_encode($timeSlots, JSON_UNESCAPED_UNICODE));

    if ($unitMinutes <= 0) $unitMinutes = 10;
    if ($unitPoints <= 0) $unitPoints = 1;

    // 将开始和结束时间转换为时间戳
    $startTimestamp = strtotime($startTime);
    $endTimestamp = strtotime($endTime);

    // 解析时间段配置，转换为分钟数（从当天00:00开始）
    $parsedSlots = [];
    foreach ($timeSlots as $slot) {
        $slotStart = $slot['start'] ?? '';
        $slotEnd = $slot['end'] ?? '';

        if (empty($slotStart) || empty($slotEnd)) continue;

        // 解析时间段的开始和结束时间
        list($sh, $sm) = explode(':', $slotStart);
        list($eh, $em) = explode(':', $slotEnd);

        $slotStartMinutes = intval($sh) * 60 + intval($sm);
        $slotEndMinutes = intval($eh) * 60 + intval($em);
        $pointsPerUnit = intval($slot['points_per_hour'] ?? 1);

        $parsedSlots[] = [
            'start' => $slotStartMinutes,
            'end' => $slotEndMinutes,
            'points_per_unit' => $pointsPerUnit,
            'label' => "{$slotStart}-{$slotEnd}"
        ];
    }

    // 按开始时间排序时间段
    usort($parsedSlots, function($a, $b) {
        return $a['start'] - $b['start'];
    });

    // 将签到时长按分钟拆分，逐分钟计算所属时间段
    $totalPoints = 0;
    $currentTimestamp = $startTimestamp;

    // 用于统计每个时间段的分钟数（用于日志输出）
    $segmentStats = [];

    while ($currentTimestamp < $endTimestamp) {
        // 获取当前时间点的小时和分钟
        $currentHour = intval(date('H', $currentTimestamp));
        $currentMinute = intval(date('i', $currentTimestamp));
        $currentTimeValue = $currentHour * 60 + $currentMinute;

        // 查找当前时间点所属的时间段
        $matchedSlot = null;
        foreach ($parsedSlots as $slot) {
            // 时间段边界：左闭右开 [start, end)
            if ($currentTimeValue >= $slot['start'] && $currentTimeValue < $slot['end']) {
                $matchedSlot = $slot;
                break;
            }
        }

        // 确定当前时间段的积分规则
        if ($matchedSlot !== null) {
            $segmentKey = $matchedSlot['label'];
            $pointsPerUnit = $matchedSlot['points_per_unit'];
        } else {
            $segmentKey = 'base';
            $pointsPerUnit = $unitPoints;
        }

        // 累计该时间段的分钟数
        if (!isset($segmentStats[$segmentKey])) {
            $segmentStats[$segmentKey] = [
                'minutes' => 0,
                'points_per_unit' => $pointsPerUnit
            ];
        }
        $segmentStats[$segmentKey]['minutes']++;

        // 移动到下一分钟
        $currentTimestamp += 60;
    }

    // 根据每个时间段的分钟数计算积分
    foreach ($segmentStats as $segmentKey => $stats) {
        $minutes = $stats['minutes'];
        $pointsPerUnit = $stats['points_per_unit'];

        // 计算单位数（向下取整）
        $units = intval(floor($minutes / $unitMinutes));
        $segmentPoints = $units * $pointsPerUnit;

        $totalPoints += $segmentPoints;

        logMsg("[CHECKIN] Segment '{$segmentKey}': {$minutes} minutes / {$unitMinutes} = {$units} units × {$pointsPerUnit} points = {$segmentPoints} points");
    }

    logMsg("[CHECKIN] Total points calculated: {$totalPoints} points for {$durationMinutes} minutes");

    return $totalPoints;
}

